use std::collections::BTreeMap; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result}; use rayon::prelude::*; // Safe for text and double-quoted attribute contexts. use html_escape::encode_double_quoted_attribute as escape; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode, percent_encode}; use crate::catalog::Repository; use crate::highlight::{Cache as GrammarCache, Highlighter}; /// Blobs larger than this get a raw download instead of a rendered page. const MAX_RENDER_BYTES: u64 = 1 << 20; const PATH: &AsciiSet = &NON_ALPHANUMERIC .remove(b'-') .remove(b'.') .remove(b'_') .remove(b'~') .remove(b'/'); /// Percent-encodes a slash-separated path for use in a URI. pub(crate) fn encode_path(path: &str) -> PercentEncode<'_> { percent_encode(path.as_bytes(), PATH) } /// `tip` names the page's ref and the commit it points at; the client reads /// them from `
` to resolve hash routes relative to /// the page. fn page( instance_name: &str, title: &str, description: &str, tip: Option<(&str, gix::ObjectId)>, body: &str, ) -> String { format!( "\n\ \n\ \n\ \n\ \n\ {title}\n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\n{noscript}{body}
\n\n\ \n", instance_name = escape(instance_name), title = escape(title), description = escape(description), format_version = crate::generate::OUTPUT_FORMAT_VERSION, tip = tip .map(|(name, oid)| format!(" data-ref=\"{}\" data-tip=\"{oid}\"", escape(name))) .unwrap_or_default(), // only repo pages lose anything to a missing client noscript = if tip.is_some() { NOSCRIPT } else { "" }, ) } const NOSCRIPT: &str = r#" "#; pub(crate) fn catalog_index(instance_name: &str, repositories: &[Repository]) -> String { let mut body = String::from("

repositories

\n"); if repositories.is_empty() { body.push_str("

no repositories found

\n"); } let mut current_user = None; for repo in repositories { if current_user != Some(repo.user.as_str()) { if current_user.is_some() { body.push_str("\n"); } current_user = Some(&repo.user); body.push_str(&format!( "

{}

\n\n"); } let title = format!("repositories - {instance_name}"); let description = format!("Git repositories hosted on {instance_name}."); page(instance_name, &title, &description, None, &body) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RefKind { Branch, Tag, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum StaticMode { Highlighted, /// Blob pages carry escaped plain text; the client highlights them. Plain, } #[derive(Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub(crate) struct BlobVersion { pub oid: String, pub mode: u16, pub touched: String, } #[derive(Clone, Copy)] pub(crate) struct BlobReuse<'a> { pub versions: &'a BTreeMap, pub previous: Option<(&'a Path, &'a BTreeMap)>, } pub struct Site<'a> { pub repo: &'a gix::Repository, pub instance_name: String, pub name: String, pub base_url: String, pub description: Option, pub clone_url: Option, } impl Site<'_> { /// Display names like `user/repo` shorten to the repo part in crumbs. fn crumb_name(&self) -> &str { self.name.rsplit('/').next().unwrap_or(&self.name) } fn ref_href(&self, tip: &RefTip) -> String { match tip.static_mode { None => format!("{}#{}", self.base_url, tip.commit_id), Some(_) => { format!("{}ref/{}/", self.base_url, encode_path(&tip.name)) } } } /// Name, description and clone command; the same on every page of the repo. fn repo_header(&self) -> String { let mut html = format!("
\n

{}

\n", escape(&self.name)); if let Some(desc) = &self.description { let _ = writeln!(html, "

{}

", escape(desc)); } html.push_str("
\n"); if let Some(url) = &self.clone_url { let _ = writeln!(html, "
git clone {}
", escape(url)); } html.push_str("
\n"); html } } /// Author, summary, short sha and date of one commit, as a status line. fn commit_panel(repo: &gix::Repository, oid: gix::ObjectId) -> String { let Ok(commit) = repo.find_object(oid).map(|o| o.try_into_commit()) else { return String::new(); }; let Ok(commit) = commit else { return String::new(); }; let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default(); let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default(); format!( "

\ {author}\ {summary}\ {short}\

\n", author = escape(&author), summary = escape(&summary), short = commit.id().shorten_or_id(), date = escape(&date_of(&commit)), ) } pub struct RefTip { pub kind: RefKind, pub name: String, pub commit_id: gix::ObjectId, pub static_mode: Option, } /// All local branches and tags, peeled to commits. Refs that don't peel to a /// commit (e.g. tags of blobs) are skipped. pub fn list_refs(repo: &gix::Repository) -> Result> { let platform = repo.references()?; let mut tips: Vec = Vec::new(); for (kind, iter) in [ (RefKind::Branch, platform.local_branches()?), (RefKind::Tag, platform.tags()?), ] { for r in iter { let mut r = r.map_err(|e| anyhow::anyhow!("failed to iterate refs: {e}"))?; let name = r.name().shorten().to_string(); // A branch and tag may share a short name. The flat /ref/ // namespace gives the branch precedence. if kind == RefKind::Tag && tips.iter().any(|t| t.kind == RefKind::Branch && t.name == name) { continue; } if let Ok(commit) = r.peel_to_commit() { tips.push(RefTip { kind, name, commit_id: commit.id, static_mode: None, }); } } } Ok(tips) } /// The branch HEAD points at, if it exists among `tips` (falling back to the /// first branch, matching forge behaviour for detached/unborn HEADs). pub fn head_tip<'a>(repo: &gix::Repository, tips: &'a [RefTip]) -> Option<&'a RefTip> { let head = repo.head_name().ok().flatten().map(|n| n.shorten().to_string()); tips.iter() .find(|t| t.kind == RefKind::Branch && Some(&t.name) == head.as_ref()) .or_else(|| tips.iter().find(|t| t.kind == RefKind::Branch)) } pub fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, contents).with_context(|| format!("writing {}", path.display())) } /// Renders the full static view of one ref (tree pages, blob pages, raw files /// for binaries) into `out`, which is typically a staging dir later swapped /// into place at `/ref//`. pub fn render_ref( site: &Site, tips: &[RefTip], tip: &RefTip, commit: &gix::Commit, out: &Path, reuse: Option>, highlights: Arc, ) -> Result<()> { let mut r = RefRenderer::new(site, tips, tip, commit, out, highlights.clone()); let tree = commit.tree()?; let mut blobs = Vec::new(); r.walk(&tree, &mut Vec::new(), &mut blobs)?; // Blob pages dominate build time and are independent of each other, so // fan out; the repository is only ever read, so each batch gets its own // thread-local handle and reusable highlighter. let ctx = BlobContext { instance_name: site.instance_name.clone(), name: site.name.clone(), base_url: site.base_url.clone(), repo_header: site.repo_header(), label: tip.name.clone(), tip_id: commit.id, highlight: tip.static_mode != Some(StaticMode::Plain), out: out.to_owned(), reuse, highlights, }; let repo = site.repo.clone().into_sync(); let chunk_size = blobs.len().div_ceil(rayon::current_num_threads()).max(1); blobs.par_chunks_mut(chunk_size).try_for_each(|jobs| { let repo = repo.to_thread_local(); let mut highlighter = Highlighter::new(ctx.highlights.clone()); jobs.iter_mut() .try_for_each(|job| blob_page(&ctx, &repo, &mut highlighter, job)) }) } /// Everything a blob page needs besides the per-worker repository handle and /// highlighter. struct BlobContext<'a> { instance_name: String, name: String, base_url: String, repo_header: String, label: String, tip_id: gix::ObjectId, highlight: bool, out: PathBuf, reuse: Option>, highlights: Arc, } struct BlobJob { path: Vec, oid: gix::ObjectId, is_link: bool, /// Switcher and crumbs, prebuilt during the walk: they need ref-wide /// state that the workers don't carry. chrome: String, } fn blob_page( ctx: &BlobContext, repo: &gix::Repository, hl: &mut Highlighter, job: &mut BlobJob, ) -> Result<()> { let joined = job.path.join("/"); let rel = format!("blob/{joined}"); if let Some(reuse) = ctx.reuse && let Some((previous, previous_versions)) = reuse.previous && reuse_blob( previous, previous_versions, reuse.versions, &joined, &ctx.out, ) { return Ok(()); } let data = &repo.find_object(job.oid)?.data.to_vec(); let size = data.len() as u64; let looks_binary = data[..data.len().min(8192)].contains(&0); let text = (!job.is_link && !looks_binary && size <= MAX_RENDER_BYTES) .then(|| String::from_utf8_lossy(data)); let lines = text.as_ref().map_or(0, |text| text.lines().count()); let stats = if job.is_link { format!("symlink \u{2192} {}", escape(&String::from_utf8_lossy(data))) } else if text.is_some() { format!( "{}{lines} line{}", human_size(size), if lines == 1 { "" } else { "s" }, ) } else { format!( "{}{}", if looks_binary { "binary file" } else { "large file" }, human_size(size), ) }; let touched = ctx .reuse .and_then(|reuse| reuse.versions.get(&joined)) .and_then(|version| gix::ObjectId::from_hex(version.touched.as_bytes()).ok()) .unwrap_or(ctx.tip_id); let mut body = format!( "{repo_header}{panel}
{chrome}{stats}\ raw
\n", repo_header = ctx.repo_header, panel = commit_panel(repo, touched), chrome = std::mem::take(&mut job.chrome), base = ctx.base_url, oid = job.oid, href = encode_path(&joined), ); if let Some(text) = text { let name = job.path.last().expect("blob path is never empty"); // Plain mode defers highlighting to the client, which detects the // language from the path carried in data-hl. let (src_attrs, code) = if ctx.highlight { let lang = crate::grammar_manifest::detect(name); let code = lang .and_then(|lang| hl.highlight_lines(lang, &text).ok()) .unwrap_or_else(|| text.lines().map(|line| escape(line).to_string()).collect()); let class = lang.map(|l| format!(" language-{l}")).unwrap_or_default(); (format!("class=\"code src{class}\""), code) } else { ( format!("class=\"code src\" data-hl=\"{}\"", escape(&joined)), text.lines().map(|line| escape(line).to_string()).collect(), ) }; let mut source = format!("
");
        let line_count = code.len();
        for (i, line) in code.into_iter().enumerate() {
            let number = i + 1;
            let _ = write!(
                source,
                "{number}{line}"
            );
            if number < line_count {
                source.push_str("\n");
            }
            source.push_str("");
        }
        source.push_str("
"); if is_markdown(name) { let rendered = markdown(hl, &text); let _ = writeln!( body, "
\ \ \ \ \
{rendered}
\
{source}
\
", ); } else { let _ = writeln!(body, "{source}"); } } let title = format!("{} - {} @ {}", joined, ctx.name, ctx.label); let description = format!("{joined} in {} at {}.", ctx.name, ctx.label); write_file( &ctx.out.join(&rel), page(&ctx.instance_name, &title, &description, Some((&ctx.label, ctx.tip_id)), &body), ) } fn reuse_blob( previous: &Path, previous_versions: &BTreeMap, versions: &BTreeMap, path: &str, out: &Path, ) -> bool { let Some(version) = versions.get(path) else { return false; }; if previous_versions.get(path) != Some(version) { return false; } let previous = previous.join("blob").join(path); let out = out.join("blob").join(path); if !previous.is_file() || out.parent().is_none_or(|parent| fs::create_dir_all(parent).is_err()) { return false; } if fs::hard_link(&previous, &out).is_ok() || fs::copy(&previous, &out).is_ok() { return true; } let _ = fs::remove_file(out); false } /// The landing page: repo header plus the HEAD branch's root tree view, with /// entry links pointing into the branch's own pages under `ref/`. pub fn render_site_index( site: &Site, tips: &[RefTip], out: &Path, highlights: Arc, ) -> Result<()> { let mut body = format!("← back\n{}", site.repo_header()); let head = head_tip(site.repo, tips); match head { None => body.push_str("

no branches yet

\n"), Some(tip) => { let commit = site.repo.find_object(tip.commit_id)?.try_into_commit()?; let tree = commit.tree()?; let mut r = RefRenderer::new(site, tips, tip, &commit, out, highlights); let entries = collect_entries(&tree)?; body.push_str(&commit_panel(site.repo, tip.commit_id)); body.push_str(&r.tree_topbar(&[], &entries)); if tip.static_mode == Some(StaticMode::Highlighted) { body.push_str(&render_languages( &crate::languages::analyze(site.repo, &tree)?, tip.commit_id, )); } body.push_str(&r.overview(&entries)); body.push_str(&r.readme_section(&entries)); } } let description = site .description .clone() .unwrap_or_else(|| format!("{} repository on {}.", site.name, site.instance_name)); write_file( &out.join("index.html"), page(&site.instance_name, &site.name, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body), ) } /// `data-tip` and `data-language` let the client turn each label into a /// link to that language's files at the tip. fn render_languages(stats: &[crate::languages::Stat], tip: gix::ObjectId) -> String { let total = stats.iter().map(|stat| stat.bytes).sum::(); if total == 0 { return String::new(); } let mut html = format!( "
\nlanguages", ); for stat in stats { let percentage = stat.bytes as f64 * 100.0 / total as f64; let _ = write!( html, "", escape(stat.name), percentage, stat.color, ); } html.push_str("\n
    \n"); for stat in stats { let percentage = stat.bytes as f64 * 100.0 / total as f64; let _ = writeln!( html, "
  • {} {:.1}%
  • ", stat.id, stat.color, escape(stat.name), percentage, ); } html.push_str("
\n
\n"); html } /// `/refs`: branch and tag tables with tip summaries and dates. pub fn render_refs_page(site: &Site, tips: &[RefTip], out: &Path) -> Result<()> { let head = head_tip(site.repo, tips); let mut body = format!( "{}
\n", site.repo_header(), site.base_url, escape(site.crumb_name()), ); for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] { // HEAD branch first, then alphabetical; tags newest first let mut dated: Vec<_> = tips .iter() .filter(|t| t.kind == kind) .map(|t| { let commit = site .repo .find_object(t.commit_id) .map_err(anyhow::Error::from) .and_then(|o| Ok(o.try_into_commit()?)); let (summary, date, secs) = match &commit { Ok(c) => ( c.message().map(|m| m.summary().to_string()).unwrap_or_default(), date_of(c), author_time(c).map(|t| t.seconds).unwrap_or(0), ), Err(_) => (String::new(), String::new(), 0), }; (t, summary, date, secs) }) .collect(); if dated.is_empty() { continue; } match kind { RefKind::Branch => dated.sort_by(|a, b| { let is_head = |t: &RefTip| head.is_some_and(|head| head.name == t.name); is_head(b.0).cmp(&is_head(a.0)).then_with(|| a.0.name.cmp(&b.0.name)) }), RefKind::Tag => dated.sort_by_key(|d| std::cmp::Reverse(d.3)), } let _ = write!(body, "

{heading}

\n\n"); for (tip, summary, date, _) in dated { let _ = writeln!( body, "", href = site.ref_href(tip), name = escape(&tip.name), summary = escape(&summary), date = escape(&date), ); } body.push_str("
{name}{summary}
\n"); } let title = format!("refs - {}", site.name); let description = format!("Branches and tags for {}.", site.name); write_file( &out.join("refs"), page(&site.instance_name, &title, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body), ) } struct RefRenderer<'a> { site: &'a Site<'a>, tips: &'a [RefTip], out: &'a Path, label: String, /// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`. urlbase: String, tip_id: gix::ObjectId, hl: Highlighter, } enum EntryKind { Dir, File, Link, Submodule, } type Entry = (String, EntryKind, gix::ObjectId); fn collect_entries(tree: &gix::Tree<'_>) -> Result> { let mut entries = Vec::new(); for entry in tree.iter() { let entry = entry?; let mode = entry.mode(); let kind = if mode.is_tree() { EntryKind::Dir } else if mode.is_link() { EntryKind::Link } else if mode.is_commit() { EntryKind::Submodule } else { EntryKind::File }; entries.push((entry.filename().to_string(), kind, entry.oid().to_owned())); } entries.sort_by(|a, b| { let rank = |k: &EntryKind| !matches!(k, EntryKind::Dir); rank(&a.1).cmp(&rank(&b.1)).then_with(|| a.0.cmp(&b.0)) }); Ok(entries) } /// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone /// directories reads better as one link than as three clicks. fn collapse_lone_dirs(repo: &gix::Repository, name: &str, mut oid: gix::ObjectId) -> String { let mut path = name.to_string(); while let Ok(tree) = repo.find_tree(oid) { let mut entries = tree.iter(); match (entries.next(), entries.next()) { (Some(Ok(only)), None) if only.mode().is_tree() => { let _ = write!(path, "/{}", only.filename()); oid = only.oid().to_owned(); } _ => break, } } path } impl<'a> RefRenderer<'a> { fn new( site: &'a Site<'a>, tips: &'a [RefTip], tip: &RefTip, commit: &gix::Commit, out: &'a Path, highlights: Arc, ) -> Self { RefRenderer { site, tips, out, label: tip.name.clone(), urlbase: format!("{}ref/{}/", site.base_url, encode_path(&tip.name)), tip_id: commit.id, hl: Highlighter::new(highlights), } } fn walk( &mut self, tree: &gix::Tree<'_>, path: &mut Vec, blobs: &mut Vec, ) -> Result<()> { let entries = collect_entries(tree)?; self.tree_page(path, &entries)?; for (name, kind, oid) in entries { path.push(name); match kind { EntryKind::Dir => { let subtree = self.site.repo.find_object(oid)?.try_into_tree()?; self.walk(&subtree, path, blobs)?; } EntryKind::File | EntryKind::Link => { blobs.push(BlobJob { chrome: format!("{}{}", self.switcher(), self.crumbs(path, true)), path: path.clone(), oid, is_link: matches!(kind, EntryKind::Link), }); } EntryKind::Submodule => {} } path.pop(); } Ok(()) } fn tree_page(&mut self, path: &[String], entries: &[Entry]) -> Result<()> { let rel = if path.is_empty() { "index.html".to_string() } else { format!("tree/{}/index.html", path.join("/")) }; let mut body = if path.is_empty() { "← back\n".to_string() } else { String::new() }; body.push_str(&self.site.repo_header()); body.push_str(&commit_panel(self.site.repo, self.tip_id)); body.push_str(&self.tree_topbar(path, entries)); if path.is_empty() { body.push_str(&self.overview(entries)); } else { body.push_str("

files

\n"); body.push_str(&self.listing(path, entries)); } body.push_str(&self.readme_section(entries)); let title = if path.is_empty() { format!("{} @ {}", self.site.name, self.label) } else { format!("{}/ - {} @ {}", path.join("/"), self.site.name, self.label) }; let description = if path.is_empty() { format!("Source tree for {} at {}.", self.site.name, self.label) } else { format!( "{} in {} at {}.", path.join("/"), self.site.name, self.label, ) }; write_file( &self.out.join(&rel), page(&self.site.instance_name, &title, &description, Some((&self.label, self.tip_id)), &body), ) } /// Root-page layout: file listing beside a recent-commits log. fn overview(&self, entries: &[Entry]) -> String { format!( "

files

\n{}
\n", self.listing(&[], entries), self.log_section(), ) } /// Recent commits from this ref's tip, jj-style: change id (from the /// `change-id` commit header jj can write), short sha, summary. fn log_section(&self) -> String { const SHOWN: usize = 10; let mut s = String::from("

recent commits

\n
    \n"); let mut shown = Vec::with_capacity(SHOWN); let mut parents = Vec::new(); let mut truncated = false; let Ok(walk) = self.site.repo.rev_walk([self.tip_id]).all() else { return String::new(); }; for (n, info) in walk.flatten().enumerate() { if n == SHOWN { truncated = true; break; } let Ok(commit) = self .site .repo .find_object(info.id) .map_err(anyhow::Error::from) .and_then(|o| Ok(o.try_into_commit()?)) else { continue; }; shown.push(info.id); parents.extend(commit.parent_ids().map(|id| id.detach())); let change_id = commit .decode() .ok() .and_then(|c| c.extra_headers().find("change-id").map(|v| v.to_string())); let cid = change_id .as_deref() .map(|c| { format!( "{} ", info.id, escape(&c[..c.len().min(8)]), ) }) .unwrap_or_default(); let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default(); let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default(); let _ = writeln!( s, "
  1. {cid}{sha} {author}{summary}
  2. ", full = info.id, sha = commit.id().shorten_or_id(), author = escape(&author), date = escape(&date_of(&commit)), summary = escape(&summary), ); } s.push_str("
\n"); if truncated { let mut frontier = Vec::new(); for parent in parents { if !shown.contains(&parent) && !frontier.contains(&parent) { frontier.push(parent); } } let frontier = frontier .iter() .map(ToString::to_string) .collect::>() .join(" "); let _ = writeln!( s, "

⋯ older commits not shown

", ); } s } fn listing(&self, path: &[String], entries: &[Entry]) -> String { let mut sub = path.join("/"); if !sub.is_empty() { sub.push('/'); } let mut body = "\n".to_string(); for (name, kind, oid) in entries { match kind { EntryKind::Dir => { let name = collapse_lone_dirs(self.site.repo, name, *oid); let _ = writeln!( body, "", base = self.urlbase, href = encode_path(&format!("{sub}{name}")), name = escape(&name), ); } EntryKind::File | EntryKind::Link => { let size = self.site.repo.find_header(*oid).map(|h| h.size()).unwrap_or(0); let _ = writeln!( body, "", base = self.urlbase, href = encode_path(&format!("{sub}{name}")), name = escape(name), sigil = if matches!(kind, EntryKind::Link) { "@" } else { "" }, size = human_size(size), ); } EntryKind::Submodule => { let _ = writeln!( body, "", name = escape(name), ); } } } body.push_str("
{name}/
{name}{sigil}{size}
{name} @ {oid:.8}
\n"); body } fn readme_section(&mut self, entries: &[Entry]) -> String { match find_readme(self.site.repo, entries) { Some((name, data)) => { let readme = readme_html(&mut self.hl, &name, &data); format!("
\n{readme}
\n") } None => String::new(), } } fn tree_topbar(&self, path: &[String], entries: &[Entry]) -> String { let folders = entries.iter().filter(|(_, kind, _)| matches!(kind, EntryKind::Dir)).count(); let files = entries.len() - folders; let mut stats = String::from(""); if folders > 0 { let _ = write!(stats, "{folders} folder{}", if folders == 1 { "" } else { "s" }); } if files > 0 { let _ = write!(stats, "{files} file{}", if files == 1 { "" } else { "s" }); } stats.push_str(""); // the empty actions slot is where the client puts the history link format!( "
{}{}{stats}
\n", self.switcher(), self.crumbs(path, false), ) } /// A no-JS `
` dropdown listing all refs. Baked at build time, so /// pages of refs untouched since a ref was added/deleted list it stale; /// the always-rebuilt index and refs pages stay fresh. fn switcher(&self) -> String { let mut s = format!( "
{}
", escape(&self.label), ); for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] { let mut group = self .tips .iter() .filter(|t| t.kind == kind && t.static_mode.is_some()) .peekable(); if group.peek().is_none() { continue; } let _ = write!(s, "{heading}"); for t in group { let current = t.name == self.label; let _ = write!( s, "{name}", class = if current { " class=\"current\"" } else { "" }, href = self.site.ref_href(t), name = escape(&t.name), ); } } let _ = write!( s, "all refs →
", self.site.base_url, ); s } /// Breadcrumb nav: site name / path components, all but the last linked. fn crumbs(&self, path: &[String], last_is_file: bool) -> String { let mut nav = format!( "\n"); nav } } /// Author date; the in-browser client also renders author time, so dates /// match site-wide. fn date_of(commit: &gix::Commit<'_>) -> String { author_time(commit) .map(|t| t.format_or_unix(gix::date::time::format::SHORT)) .unwrap_or_default() } fn author_time(commit: &gix::Commit<'_>) -> Option { commit.author().ok()?.time().ok() } fn find_readme(repo: &gix::Repository, entries: &[Entry]) -> Option<(String, Vec)> { ["readme.md", "readme", "readme.txt"].iter().find_map(|want| { entries.iter().find_map(|(name, kind, oid)| { (matches!(kind, EntryKind::File) && name.to_lowercase() == *want) .then(|| Some((name.clone(), repo.find_object(*oid).ok()?.data.to_vec()))) .flatten() }) }) } fn is_markdown(name: &str) -> bool { let name = name.to_lowercase(); name.ends_with(".md") || name.ends_with(".markdown") } fn readme_html(hl: &mut Highlighter, name: &str, data: &[u8]) -> String { let text = String::from_utf8_lossy(data); if is_markdown(name) { markdown(hl, &text) } else { format!("
{}
\n", escape(&text)) } } fn markdown(hl: &mut Highlighter, src: &str) -> String { use pulldown_cmark::{html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_FOOTNOTES | Options::ENABLE_TASKLISTS; // Buffer fenced code blocks so they get the same arborium treatment as // blob pages; escape raw HTML rather than pulling in a sanitizer. let mut fence: Option<(Option<&'static str>, String)> = None; let events = Parser::new_ext(src, opts).filter_map(|ev| match ev { Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => { // only the first word names the language: ```rust,ignore let lang = info .split(|c: char| c == ',' || c.is_whitespace()) .next() .and_then(crate::grammar_manifest::canonical); fence = Some((lang, String::new())); None } Event::Text(t) if fence.is_some() => { fence.as_mut().expect("checked in guard").1.push_str(&t); None } Event::End(TagEnd::CodeBlock) if fence.is_some() => { let (lang, code) = fence.take().expect("checked in guard"); let class = lang.map(|l| format!(" class=\"language-{l}\"")).unwrap_or_default(); let code = lang .and_then(|lang| hl.highlight(lang, &code).ok()) .unwrap_or_else(|| escape(&code).to_string()); Some(Event::Html(format!("{code}\n").into())) } Event::Html(s) => Some(Event::Text(s)), Event::InlineHtml(s) => Some(Event::Text(s)), ev => Some(ev), }); let mut out = String::new(); html::push_html(&mut out, events); out } fn human_size(bytes: u64) -> String { const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"]; if bytes < 1024 { return format!("{bytes} B"); } let mut size = bytes as f64; let mut unit = ""; for u in UNITS { size /= 1024.0; unit = u; if size < 1024.0 { break; } } format!("{size:.1} {unit}") } #[cfg(test)] mod tests { use std::collections::BTreeMap; use std::fs; use std::os::unix::fs::symlink; use anyhow::Result; use crate::highlight::Highlighter; use crate::testutil::{TempDir, commit, git, grammar_cache, init_repo}; use super::{ BlobVersion, EntryKind, RefKind, collapse_lone_dirs, collect_entries, encode_path, find_readme, head_tip, human_size, list_refs, markdown, reuse_blob, }; #[test] fn encodes_url_paths() { assert_eq!(encode_path("a b/c?d#\u{e9}").to_string(), "a%20b/c%3Fd%23%C3%A9"); } #[test] fn human_sizes_switch_units_at_1024() { assert_eq!(human_size(0), "0 B"); assert_eq!(human_size(1023), "1023 B"); assert_eq!(human_size(1024), "1.0 KiB"); assert_eq!(human_size(1536), "1.5 KiB"); assert_eq!(human_size(1 << 20), "1.0 MiB"); assert_eq!(human_size(1 << 40), "1.0 TiB"); assert_eq!(human_size(1 << 50), "1024.0 TiB"); } #[test] fn tree_entries_list_directories_first_then_by_name() -> Result<()> { let root = TempDir::new("entries"); let repo_path = root.join("repo"); init_repo(&repo_path)?; for dir in ["zeta", "alpha"] { fs::create_dir(repo_path.join(dir))?; fs::write(repo_path.join(dir).join("keep"), "")?; } fs::write(repo_path.join("beta"), "")?; fs::write(repo_path.join("aardvark"), "")?; symlink("beta", repo_path.join("gamma"))?; commit(&repo_path, "first")?; let repo = gix::open(&repo_path)?; let entries = collect_entries(&repo.head_commit()?.tree()?)?; let names: Vec<_> = entries.iter().map(|(name, ..)| name.as_str()).collect(); assert_eq!(names, ["alpha", "zeta", "aardvark", "beta", "gamma"]); assert!(matches!(entries[1].1, EntryKind::Dir)); assert!(matches!(entries[2].1, EntryKind::File)); assert!(matches!(entries[4].1, EntryKind::Link)); Ok(()) } #[test] fn lone_directory_chains_collapse_into_one_name() -> Result<()> { let root = TempDir::new("collapse"); let repo_path = root.join("repo"); init_repo(&repo_path)?; fs::create_dir_all(repo_path.join("a/b/c"))?; fs::write(repo_path.join("a/b/c/keep"), "")?; fs::create_dir_all(repo_path.join("x/y"))?; fs::write(repo_path.join("x/y/keep"), "")?; fs::write(repo_path.join("x/stop"), "")?; commit(&repo_path, "first")?; let repo = gix::open(&repo_path)?; let entries = collect_entries(&repo.head_commit()?.tree()?)?; let collapsed: Vec<_> = entries .iter() .map(|(name, _, oid)| collapse_lone_dirs(&repo, name, *oid)) .collect(); assert_eq!(collapsed, ["a/b/c", "x"]); Ok(()) } #[test] fn readme_lookup_prefers_markdown_ignores_case_and_skips_directories() -> Result<()> { let root = TempDir::new("readme"); let repo_path = root.join("repo"); init_repo(&repo_path)?; fs::write(repo_path.join("readme.txt"), "txt")?; fs::write(repo_path.join("README"), "bare")?; fs::write(repo_path.join("ReadMe.MD"), "md")?; commit(&repo_path, "first")?; let repo = gix::open(&repo_path)?; let entries = collect_entries(&repo.head_commit()?.tree()?)?; let (name, data) = find_readme(&repo, &entries).unwrap(); assert_eq!((name.as_str(), data.as_slice()), ("ReadMe.MD", b"md".as_slice())); fs::remove_file(repo_path.join("ReadMe.MD"))?; fs::create_dir(repo_path.join("readme.md"))?; fs::write(repo_path.join("readme.md/keep"), "")?; commit(&repo_path, "second")?; let entries = collect_entries(&repo.head_commit()?.tree()?)?; let (name, data) = find_readme(&repo, &entries).unwrap(); assert_eq!((name.as_str(), data.as_slice()), ("README", b"bare".as_slice())); Ok(()) } #[test] fn refs_skip_non_commit_tags_and_let_branches_shadow_tags() -> Result<()> { let root = TempDir::new("refs"); let repo_path = root.join("repo"); init_repo(&repo_path)?; fs::write(repo_path.join("file"), "")?; commit(&repo_path, "first")?; git(&repo_path, &["tag", "main"])?; git(&repo_path, &["tag", "v1"])?; let blob = git(&repo_path, &["rev-parse", "HEAD:file"])?; git(&repo_path, &["tag", "blobtag", &blob])?; let repo = gix::open(&repo_path)?; let mut tips: Vec<_> = list_refs(&repo)? .into_iter() .map(|tip| (tip.kind, tip.name)) .collect(); tips.sort_by(|a, b| a.1.cmp(&b.1)); assert_eq!(tips, [(RefKind::Branch, "main".into()), (RefKind::Tag, "v1".into())]); Ok(()) } #[test] fn head_tip_follows_head_and_falls_back_to_any_branch() -> Result<()> { let root = TempDir::new("head"); let repo_path = root.join("repo"); init_repo(&repo_path)?; fs::write(repo_path.join("file"), "")?; commit(&repo_path, "first")?; git(&repo_path, &["branch", "aaa"])?; let repo = gix::open(&repo_path)?; let tips = list_refs(&repo)?; assert_eq!(head_tip(&repo, &tips).unwrap().name, "main"); git(&repo_path, &["checkout", "-q", "--detach"])?; let repo = gix::open(&repo_path)?; assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch); git(&repo_path, &["symbolic-ref", "HEAD", "refs/heads/unborn"])?; let repo = gix::open(&repo_path)?; assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch); Ok(()) } #[test] fn markdown_never_passes_through_raw_html() { let mut hl = Highlighter::new(grammar_cache()); let html = markdown( &mut hl, "\n\n\ text bold\n\n\ ```no-such-lang\n\n```\n", ); assert!(html.contains("<script>"), "{html}"); for forbidden in [" Result<()> { let root = TempDir::new("blob-reuse"); let previous = root.join("previous"); let out = root.join("out"); fs::create_dir_all(previous.join("blob/src"))?; fs::write(previous.join("blob/src/main.rs"), "highlighted")?; let old = BlobVersion { oid: "abc123".into(), mode: 0o100644, touched: "first".into(), }; let previous_versions = BTreeMap::from([("src/main.rs".into(), old.clone())]); let changed = BlobVersion { touched: "second".into(), ..old.clone() }; let changed_versions = BTreeMap::from([("src/main.rs".into(), changed)]); let versions = BTreeMap::from([("src/main.rs".into(), old)]); assert!(!reuse_blob( &previous, &previous_versions, &changed_versions, "src/main.rs", &out, )); assert!(reuse_blob( &previous, &previous_versions, &versions, "src/main.rs", &out, )); assert_eq!( fs::read_to_string(out.join("blob/src/main.rs"))?, "highlighted", ); Ok(()) } }